Micron Document
🎖️GitЯра🎖️

Commit 9613ba91a7e169afe8ce2e98c6d384ef37f03421


Parents : 92efb53
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T02:11:37-10:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T12:11:37Z

fix(node): stop compass updates while backgrounded (#6620)

Changes
Diff

diff --git a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
index 2669adac83..9e32514671 100644
--- a/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
+++ b/androidApp/src/google/kotlin/org/meshtastic/app/map/MapView.kt
@@ -162,6 +162,7 @@ import org.meshtastic.core.ui.icon.Map
import org.meshtastic.core.ui.icon.MeshtasticIcons
import org.meshtastic.core.ui.icon.TripOrigin
import org.meshtastic.core.ui.theme.TracerouteColors
+import org.meshtastic.core.ui.util.ActiveWhileStarted
import org.meshtastic.core.ui.util.KeepScreenOn
import org.meshtastic.core.ui.util.PermissionStatus
import org.meshtastic.core.ui.util.formatAgo
@@ -351,7 +352,7 @@ fun MapView(
}
}
- ActiveWhileStarted(isLocationTrackingEnabled && locationPermission.isGranted) {
+ ActiveWhileStarted(enabled = isLocationTrackingEnabled && locationPermission.isGranted) {
val locationRequest =
LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000L).setMinUpdateIntervalMillis(2000L).build()
try {

diff --git a/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLifecycleEffects.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ActiveWhileStarted.kt
similarity index 64%
rename from androidApp/src/main/kotlin/org/meshtastic/app/map/MapLifecycleEffects.kt
rename to core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ActiveWhileStarted.kt
index be8f6302be..496bcfdeaa 100644
--- a/androidApp/src/main/kotlin/org/meshtastic/app/map/MapLifecycleEffects.kt
+++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/util/ActiveWhileStarted.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-package org.meshtastic.app.map
+package org.meshtastic.core.ui.util
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -22,16 +22,17 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.compose.LifecycleStartEffect
/**
- * Runs [effect] only while the current lifecycle is at least STARTED and [enabled] is true.
+ * Runs [effect] while the current lifecycle is at least STARTED and [enabled] is true.
*
- * The cleanup returned by [effect] runs synchronously on ON_STOP, disable, or composition disposal. This is important
- * for hardware work: a coroutine launched from an ON_STOP state change may not run after the host recomposer pauses.
+ * Changing a [restartKeys] value restarts an active effect. The returned cleanup callback is invoked synchronously on
+ * ON_STOP, disable, or composition disposal. If that callback cancels coroutine work, downstream cleanup such as a
+ * `callbackFlow` provider's `awaitClose` runs as cancellation resumes.
*/
@Composable
-internal fun ActiveWhileStarted(enabled: Boolean, effect: () -> () -> Unit) {
+fun ActiveWhileStarted(vararg restartKeys: Any?, enabled: Boolean = true, effect: () -> () -> Unit) {
val currentEffect by rememberUpdatedState(effect)
- LifecycleStartEffect(enabled) {
+ LifecycleStartEffect(enabled, *restartKeys) {
val cleanup = if (enabled) currentEffect() else ({})
onStopOrDispose { cleanup() }
}

diff --git a/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLifecycleEffectsTest.kt b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ActiveWhileStartedTest.kt
similarity index 54%
rename from androidApp/src/test/kotlin/org/meshtastic/app/map/MapLifecycleEffectsTest.kt
rename to core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ActiveWhileStartedTest.kt
index 4ba72121ef..6ed6a5138d 100644
--- a/androidApp/src/test/kotlin/org/meshtastic/app/map/MapLifecycleEffectsTest.kt
+++ b/core/ui/src/commonTest/kotlin/org/meshtastic/core/ui/util/ActiveWhileStartedTest.kt
@@ -14,7 +14,7 @@
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
-package org.meshtastic.app.map
+package org.meshtastic.core.ui.util
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
@@ -26,65 +26,82 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.compose.LocalLifecycleOwner
-import org.junit.Test
-import org.junit.runner.RunWith
-import org.robolectric.RobolectricTestRunner
-import org.robolectric.annotation.Config
+import kotlin.test.Test
import kotlin.test.assertEquals
@OptIn(ExperimentalTestApi::class)
-@RunWith(RobolectricTestRunner::class)
-@Config(sdk = [34])
-class MapLifecycleEffectsTest {
+class ActiveWhileStartedTest {
@Test
- fun activeEffectStopsInBackgroundAndResumesOnlyWhenEnabled() = runComposeUiTest {
+ fun effectFollowsLifecycleEnablementRestartKeysAndDisposal() = runComposeUiTest {
val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.CREATED)
+ val starts = mutableListOf<Pair<String, Int>>()
+ val stops = mutableListOf<Pair<String, Int>>()
var enabled by mutableStateOf(true)
+ var restartKey by mutableStateOf("first")
+ var effectVersion by mutableStateOf(1)
var composed by mutableStateOf(true)
- var starts = 0
- var stops = 0
setContent {
CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
if (composed) {
- ActiveWhileStarted(enabled) {
- starts += 1
- { stops += 1 }
+ ActiveWhileStarted(restartKey, enabled = enabled) {
+ val startedKey = restartKey
+ val startedVersion = effectVersion
+ starts += startedKey to startedVersion
+ { stops += startedKey to startedVersion }
}
}
}
}
- assertEquals(0, starts)
+ assertEquals(emptyList(), starts, "CREATED must not start the effect")
+ assertEquals(emptyList(), stops)
+
lifecycleOwner.moveTo(Lifecycle.State.STARTED)
waitForIdle()
- assertEquals(1, starts)
+ assertEquals(listOf("first" to 1), starts)
+
+ runOnIdle { effectVersion = 2 }
+ waitForIdle()
+ assertEquals(listOf("first" to 1), starts, "updating the callback alone must not restart the effect")
lifecycleOwner.moveTo(Lifecycle.State.CREATED)
waitForIdle()
- assertEquals(1, stops, "ON_STOP must synchronously release active map work")
+ assertEquals(listOf("first" to 1), stops, "ON_STOP must invoke the active cleanup callback")
lifecycleOwner.moveTo(Lifecycle.State.STARTED)
waitForIdle()
- assertEquals(2, starts, "ON_START must resume an enabled map tracker")
+ assertEquals(listOf("first" to 1, "first" to 2), starts, "resume must use the latest effect callback")
+
+ runOnIdle { restartKey = "second" }
+ waitForIdle()
+ assertEquals(listOf("first" to 1, "first" to 2), stops)
+ assertEquals(listOf("first" to 1, "first" to 2, "second" to 2), starts)
runOnIdle { enabled = false }
waitForIdle()
- assertEquals(2, stops, "disabling tracking must release active work")
+ assertEquals(listOf("first" to 1, "first" to 2, "second" to 2), stops)
lifecycleOwner.moveTo(Lifecycle.State.CREATED)
lifecycleOwner.moveTo(Lifecycle.State.STARTED)
waitForIdle()
- assertEquals(2, starts, "a disabled tracker must remain stopped after resume")
+ assertEquals(3, starts.size, "a disabled effect must remain stopped across lifecycle changes")
+ assertEquals(3, stops.size)
runOnIdle { enabled = true }
waitForIdle()
- assertEquals(3, starts)
+ assertEquals(listOf("first" to 1, "first" to 2, "second" to 2, "second" to 2), starts)
runOnIdle { composed = false }
waitForIdle()
- assertEquals(3, stops, "composition disposal must release active map work")
+ assertEquals(listOf("first" to 1, "first" to 2, "second" to 2, "second" to 2), stops)
+
+ lifecycleOwner.moveTo(Lifecycle.State.CREATED)
+ lifecycleOwner.moveTo(Lifecycle.State.STARTED)
+ waitForIdle()
+ assertEquals(4, starts.size, "a disposed effect must not restart")
+ assertEquals(4, stops.size, "disposal must not double-clean")
}
private class TestLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
index 216edcfaae..ea499908ee 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/compass/CompassViewModel.kt
@@ -104,6 +104,10 @@ class CompassViewModel(
}
}
+ /**
+ * Marks the update job cancelled immediately. Provider listeners are unregistered by their `awaitClose` handlers as
+ * cancellation propagates through the flows.
+ */
fun stop() {
updatesJob?.cancel()
updatesJob = null

diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
index 8d6c4ec67e..81f31631fa 100644
--- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
+++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt
@@ -23,7 +23,6 @@ import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -41,19 +40,21 @@ import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.details
import org.meshtastic.core.ui.component.MainAppBar
import org.meshtastic.core.ui.component.SharedContactDialog
+import org.meshtastic.core.ui.util.ActiveWhileStarted
import org.meshtastic.feature.node.compass.CompassUiState
import org.meshtastic.feature.node.compass.CompassViewModel
import org.meshtastic.feature.node.component.CompassSheetContent
import org.meshtastic.feature.node.component.FirmwareReleaseSheetContent
import org.meshtastic.feature.node.component.NodeMenuAction
import org.meshtastic.feature.node.model.NodeDetailAction
+import org.meshtastic.proto.Config
private sealed interface NodeDetailOverlay {
data object SharedContact : NodeDetailOverlay
data class FirmwareReleaseInfo(val release: FirmwareRelease) : NodeDetailOverlay
- data object Compass : NodeDetailOverlay
+ data class Compass(val nodeNum: Int, val displayUnits: Config.DisplayConfig.DisplayUnits) : NodeDetailOverlay
}
@Composable
@@ -121,10 +122,8 @@ private fun NodeDetailScaffold(
when (action) {
is NodeDetailAction.ShareContact -> activeOverlay = NodeDetailOverlay.SharedContact
- is NodeDetailAction.OpenCompass -> {
- actualCompassViewModel?.start(action.node, action.displayUnits)
- activeOverlay = NodeDetailOverlay.Compass
- }
+ is NodeDetailAction.OpenCompass ->
+ activeOverlay = NodeDetailOverlay.Compass(action.node.num, action.displayUnits)
else ->
handleNodeAction(
@@ -186,13 +185,16 @@ private fun NodeDetailOverlays(
NodeDetailBottomSheet(onDismiss) { FirmwareReleaseSheetContent(firmwareRelease = overlay.release) }
is NodeDetailOverlay.Compass -> {
- DisposableEffect(Unit) { onDispose { compassViewModel?.stop() } }
- NodeDetailBottomSheet(
- onDismiss = {
- compassViewModel?.stop()
- onDismiss()
- },
- ) {
+ val targetNode = node?.takeIf { it.num == overlay.nodeNum }
+ if (targetNode != null) {
+ compassViewModel?.let { viewModel ->
+ ActiveWhileStarted(targetNode, overlay.displayUnits, viewModel) {
+ viewModel.start(targetNode, overlay.displayUnits)
+ viewModel::stop
+ }
+ }
+ }
+ NodeDetailBottomSheet(onDismiss = onDismiss) {
CompassSheetContent(
uiState = compassUiState,
onRequestLocationPermission = onRequestLocationPermission,

diff --git a/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt
new file mode 100644
index 0000000000..2f5d3e54c8
--- /dev/null
+++ b/feature/node/src/jvmTest/kotlin/org/meshtastic/feature/node/detail/NodeDetailCompassLifecycleTest.kt
@@ -0,0 +1,209 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.node.detail
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.performScrollTo
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.v2.runComposeUiTest
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.SavedStateHandle
+import androidx.lifecycle.ViewModelStore
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import dev.mokkery.answering.returns
+import dev.mokkery.every
+import dev.mokkery.matcher.any
+import dev.mokkery.mock
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.channels.awaitClose
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.callbackFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.setMain
+import org.meshtastic.core.di.CoroutineDispatchers
+import org.meshtastic.core.domain.usecase.session.EnsureRemoteAdminSessionUseCase
+import org.meshtastic.core.domain.usecase.session.ObserveRemoteAdminSessionStatusUseCase
+import org.meshtastic.core.model.Node
+import org.meshtastic.core.model.SessionStatus
+import org.meshtastic.core.repository.QueryController
+import org.meshtastic.core.resources.Res
+import org.meshtastic.core.resources.UiText
+import org.meshtastic.core.resources.compass_title
+import org.meshtastic.core.resources.getString
+import org.meshtastic.core.resources.open_compass
+import org.meshtastic.core.ui.util.SnackbarManager
+import org.meshtastic.feature.node.compass.CompassHeadingProvider
+import org.meshtastic.feature.node.compass.CompassViewModel
+import org.meshtastic.feature.node.compass.HeadingState
+import org.meshtastic.feature.node.compass.MagneticFieldProvider
+import org.meshtastic.feature.node.compass.PhoneLocationProvider
+import org.meshtastic.feature.node.compass.PhoneLocationState
+import org.meshtastic.feature.node.domain.usecase.GetNodeDetailsUseCase
+import org.meshtastic.feature.node.model.MetricsState
+import org.meshtastic.proto.Position
+import org.meshtastic.proto.User
+import kotlin.test.AfterTest
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTestApi::class)
+class NodeDetailCompassLifecycleTest {
+
+ private val testDispatcher = UnconfinedTestDispatcher()
+
+ @BeforeTest
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @AfterTest
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun compassSelectionFollowsScreenLifecycleAndDismissal() = runComposeUiTest {
+ val viewModelStore = ViewModelStore()
+ try {
+ val lifecycleOwner = TestLifecycleOwner(Lifecycle.State.STARTED)
+ val headingProvider = RecordingHeadingProvider()
+ val node =
+ Node(
+ num = 1234,
+ user = User(id = "!000004d2", long_name = "Compass target"),
+ position = Position(latitude_i = 10000000, longitude_i = 10000000),
+ )
+ val nodeDetailViewModel = createNodeDetailViewModel(node)
+ viewModelStore.put("node-detail", nodeDetailViewModel)
+ val compassViewModel =
+ CompassViewModel(
+ headingProvider = headingProvider,
+ phoneLocationProvider = StaticPhoneLocationProvider,
+ magneticFieldProvider = ZeroMagneticFieldProvider,
+ dispatchers =
+ CoroutineDispatchers(io = testDispatcher, main = testDispatcher, default = testDispatcher),
+ )
+ viewModelStore.put("compass", compassViewModel)
+
+ setContent {
+ CompositionLocalProvider(LocalLifecycleOwner provides lifecycleOwner) {
+ MaterialTheme {
+ NodeDetailScreen(
+ nodeId = node.num,
+ viewModel = nodeDetailViewModel,
+ compassViewModel = compassViewModel,
+ )
+ }
+ }
+ }
+
+ val openCompass = getString(Res.string.open_compass)
+ onNodeWithText(openCompass).performScrollTo().performClick()
+ onNodeWithText(getString(Res.string.compass_title)).assertExists()
+ waitUntil { headingProvider.starts == 1 }
+
+ lifecycleOwner.moveTo(Lifecycle.State.CREATED)
+ waitUntil { headingProvider.stops == 1 }
+
+ lifecycleOwner.moveTo(Lifecycle.State.STARTED)
+ waitUntil { headingProvider.starts == 2 }
+
+ onNode(SemanticsMatcher.keyIsDefined(SemanticsActions.Dismiss), useUnmergedTree = true)
+ .performSemanticsAction(SemanticsActions.Dismiss)
+ waitUntil { headingProvider.stops == 2 }
+ onNodeWithText(getString(Res.string.compass_title)).assertDoesNotExist()
+
+ lifecycleOwner.moveTo(Lifecycle.State.CREATED)
+ lifecycleOwner.moveTo(Lifecycle.State.STARTED)
+ waitForIdle()
+ assertEquals(2, headingProvider.starts, "a dismissed compass must not restart")
+ assertEquals(2, headingProvider.stops, "dismissal must clean up exactly once")
+ } finally {
+ viewModelStore.clear()
+ }
+ }
+
+ private fun createNodeDetailViewModel(node: Node): NodeDetailViewModel {
+ val getNodeDetailsUseCase: GetNodeDetailsUseCase = mock()
+ val observeSessionStatus: ObserveRemoteAdminSessionStatusUseCase = mock()
+ every { getNodeDetailsUseCase(any()) } returns
+ flowOf(
+ NodeDetailUiState(
+ node = node,
+ nodeName = UiText.DynamicString(node.user.long_name),
+ metricsState = MetricsState(),
+ ),
+ )
+ every { observeSessionStatus(any()) } returns flowOf(SessionStatus.NoSession)
+
+ return NodeDetailViewModel(
+ savedStateHandle = SavedStateHandle(),
+ nodeManagementActions = mock<NodeManagementActions>(),
+ nodeRequestActions = mock<NodeRequestActions>(),
+ queryController = mock<QueryController>(),
+ getNodeDetailsUseCase = getNodeDetailsUseCase,
+ ensureRemoteAdminSession = mock<EnsureRemoteAdminSessionUseCase>(),
+ observeRemoteAdminSessionStatus = observeSessionStatus,
+ snackbarManager = SnackbarManager(),
+ )
+ }
+
+ private class TestLifecycleOwner(initialState: Lifecycle.State) : LifecycleOwner {
+ override val lifecycle: LifecycleRegistry =
+ LifecycleRegistry.createUnsafe(this).apply { currentState = initialState }
+
+ fun moveTo(state: Lifecycle.State) {
+ lifecycle.currentState = state
+ }
+ }
+
+ private class RecordingHeadingProvider : CompassHeadingProvider {
+ var starts = 0
+ private set
+
+ var stops = 0
+ private set
+
+ override fun headingUpdates(): Flow<HeadingState> = callbackFlow {
+ starts += 1
+ trySend(HeadingState(heading = 0f))
+ awaitClose { stops += 1 }
+ }
+ }
+
+ private data object StaticPhoneLocationProvider : PhoneLocationProvider {
+ override fun locationUpdates(): Flow<PhoneLocationState> =
+ MutableStateFlow(PhoneLocationState(permissionGranted = true, providerEnabled = true))
+ }
+
+ private data object ZeroMagneticFieldProvider : MagneticFieldProvider {
+ override fun getDeclination(latitude: Double, longitude: Double, altitude: Double, timeMillis: Long): Float = 0f
+ }
+}

Served by rngit 1.5.0 - Generated in 0.1s